昨天見識了俠客 Face Landmarker 的絕技三式,府上老爺當場拍桌:「這人才,我要了!」
請高手入府,必須先張羅妥當!
先收拾一間廂房讓他住下(模型檔打包進 App),
再養幾隻信鴿,府裡有事飛鴿傳書,他收到就出手,打完再把戰報傳回來(原生端橋接)。
至於怎麼請?
江湖上有現成的牙行(社群套件)能代為引薦,也可以親自登門迎請,各有利弊,得好好掂量。
今天就來看看,如何把 Face Landmarker 請進專案!
GitHub Repo:
https://github.com/NamoAmitabhaTW/kenkou-app.git
| 項目 | 檢查點 | 專案位置 |
|---|---|---|
| 1. 相依套件與最低系統版本 | 對照官方設定指南(iOS、Android)確認套件版本與最低支援版本 | iOS:face_mesh.podspec;Android:build.gradle |
| 2. 模型檔 | face_landmarker.task 已打包進 App | iOS:face_mesh.podspec;Android:build.gradle(模型放在 packages/face_mesh/ios/Resources,兩平台共用) |
| 3. 載入模型 | 找不到模型時回報錯誤,App 不閃退 | iOS:FaceMeshPlugin.swift;Android:FaceLandmarkerService.kt |
| 4. 模型參數 | 設定執行模式(預設為 IMAGE);需要 blendshape 時要手動開啟;確認偵測、存在與追蹤三項信心門檻(預設皆為 0.5)。 | iOS:FaceLandmarkerService.swift;Android:FaceLandmarkerService.kt |
| 5. 宣告權限 | 已宣告相機權限 | iOS:Info.plist;Android:AndroidManifest.xml |
| 6. 輸入影格 | 影格已轉正;確認是否鏡像(會影響 blendshape 的左右);VIDEO、LIVE_STREAM 模式的時間戳要嚴格遞增 | iOS:CameraSession.swift;Android:FaceLandmarkerService.kt |
| 7. 回傳結果 | 沒偵測到臉時也要回報;要在畫面上疊標記時,附上影格尺寸供換算座標 | iOS:FaceLandmarkerService.swift;Android:FaceLandmarkerService.kt |
defaultConfig {
minSdk = 24 // MediaPipe Tasks 下限
consumerProguardFiles("consumer-rules.pro") // R8 規則
}
dependencies {
def camerax_version = "1.4.2"
implementation("com.google.mediapipe:tasks-vision:<專案使用的版本>")
implementation("androidx.camera:camera-core:$camerax_version")
implementation("androidx.camera:camera-camera2:$camerax_version")
implementation("androidx.camera:camera-lifecycle:$camerax_version")
implementation("androidx.camera:camera-view:$camerax_version")
}
坑點:debug 版正常,release 版閃退
Flutter 的 release 版預設會開啟 R8。R8 會刪掉「看起來沒用到」的類別,並把類別改成很短的名字。MediaPipe 有些地方是用名字找類別的,包括 JNI、protobuf 反射、Flogger,R8 看不到這些用法,App 就會在執行時閃退。要自己加:
# consumer-rules.pro
-keep class com.google.mediapipe.** { *; }
-keep class com.google.protobuf.** { *; }
-keep class com.google.common.flogger.** { *; }
-dontwarn com.google.mediapipe.**
-dontwarn com.google.protobuf.**
-dontwarn com.google.common.flogger.**
sourceSets {
main.java.srcDirs += "src/main/kotlin"
// 模型直接用 iOS 那份
main.assets.srcDirs += "../ios/Resources"
}
private fun loadModel(context: Context): ByteBuffer? = try {
context.assets.open(MODEL_ASSET).use { input -> // MODEL_ASSET = "face_landmarker.task"
val bytes = input.readBytes()
ByteBuffer.allocateDirect(bytes.size)
.order(ByteOrder.nativeOrder())
.put(bytes)
.apply { rewind() }
}
} catch (e: IOException) {
null // 呼叫端轉成 no_model 回報給 Dart
}
參數和 iOS 完全相同,見 Day8 參數表。
fun create(context: Context): FaceLandmarkerService {
val model = loadModel(context) ?: throw ModelMissing()
lateinit var service: FaceLandmarkerService
val options = FaceLandmarker.FaceLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).build())
.setRunningMode(RunningMode.LIVE_STREAM)
.setNumFaces(1)
.setOutputFaceBlendshapes(true)
.setMinFaceDetectionConfidence(0.5f)
.setMinFacePresenceConfidence(0.5f)
.setMinTrackingConfidence(0.5f)
.setResultListener { result, input -> service.handle(result, input) } // LIVE_STREAM 一定要設
.setErrorListener { e -> Log.w(TAG, "FaceLandmarker 錯誤: $e") }
.build()
service = FaceLandmarkerService(FaceLandmarker.createFromOptions(context, options))
return service
}
這份 manifest 在 build 時會自動合併進 App,App 自己的 manifest 不用改。
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
// CameraSession.kt
return ImageAnalysis.Builder()
.setResolutionSelector(resolution) // 鎖 4:3、目標 640×480
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) // 處理不完就丟舊的
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) // 直接要 RGBA
.build()
.also { analysis ->
analysis.setAnalyzer(analysisExecutor) { image ->
val handler = onFrame
if (handler != null) handler(image) else image.close() // 沒人處理也要 close
}
}
// FaceLandmarkerService.kt
@Synchronized
fun detect(image: ImageProxy) {
// …省略已關閉時的檢查
// 時間戳
val timestampMs = image.imageInfo.timestamp / 1_000_000
if (timestampMs <= lastTimestampMs) {
image.close()
return
}
lastTimestampMs = timestampMs
val rotation = image.imageInfo.rotationDegrees
val raw = image.use { it.toBitmap() }
try {
landmarker.detectAsync(upright(raw, rotation), timestampMs)
} catch (e: Exception) {
Log.w(TAG, "detectAsync 失敗: $e")
}
}
private fun upright(bitmap: Bitmap, rotationDegrees: Int): MPImage {
val rotated = if (rotationDegrees == 0) {
bitmap
} else {
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
return BitmapImageBuilder(rotated).build()
}
兩個坑要注意:
- 每張 ImageProxy 用完都要 close。 不然 CameraX 不會送下一張影格。
- 預覽和分析兩邊都要鎖 4:3。 兩者會各自選解析度,比例不一致的話,畫在臉上的標記會整片偏掉。
邏輯和 iOS 相同(見 Day8 的(五)):沒偵測到臉也要回報,有臉的話就取出 blendshape 和需要的點位,連同影格尺寸打包成 FaceFrame。
// 照 CONTOUR_INDICES 點名,只取用得到的點
// 每個點的 x、y 攤平成一維:[x0, y0, x1, y1, ...]
val contour = FloatArray(CONTOUR_INDICES.size * 2)
CONTOUR_INDICES.forEachIndexed { i, index ->
contour[i * 2] = landmarks[index].x()
contour[i * 2 + 1] = landmarks[index].y()
}
要特別注意:CONTOUR_INDICES 的點位索引和順序,必須和 iOS 的 contourIndices 完全相同。Dart 端是照固定位移切資料的,兩邊只要差一個點,Dart 切出來的嘴唇和臉頰就會錯位。
| 項目 | 檢查點 | 專案位置 |
|---|---|---|
| 1. 註冊 plugin | plugin 的 pubspec 宣告各平台的進入類別(Android 另需 package);App 的 pubspec 引入 plugin | plugin:pubspec.yaml;App 端:pubspec.yaml |
| 2. 原生端通道 | 通道名稱與 Dart 端一字不差(含 PlatformView 的 viewType) | iOS:FaceMeshPlugin.swift;Android:FaceMeshPlugin.kt |
| 3. 取得 Activity | Android 的 plugin 實作 ActivityAware,Activity 結束時停止相機 | Android:FaceMeshPlugin.kt |
| 4. 請求相機權限 | 執行期能跳出權限詢問並收到結果;Android 的 request code 在 16 bit 以內 | iOS:CameraSession.swift;Android:FaceMeshPlugin.kt |
| 5. 處理 start | 兩平台的方法名稱、參數、錯誤碼一致;未知方法回傳 NotImplemented | iOS:FaceMeshPlugin.swift;Android:FaceMeshPlugin.kt |
| 6. 在主執行緒推送結果 | 兩平台的事件欄位一致;陣列用 Float32List 等 typed data 傳送,點位順序(見第二章步驟 7)和 Dart 端一致;呼叫 EventSink 前切回主執行緒 | iOS:FaceMeshPlugin.swift;Android:FaceMeshPlugin.kt |
| 7. 嵌入相機預覽 | 兩平台預覽的鏡像與縮放方式一致,疊圖座標才能共用;Android 的 PreviewView 要設為 COMPATIBLE | iOS:PreviewView.swift;Android:PreviewView.kt |
| 8. Dart 端通道 | 預覽依平台使用 AndroidView 或 UiKitView;直接使用 receiveBroadcastStream,不再另外套 asBroadcastStream | Dart:face_mesh.dart |
Day7 我們看過原生 iOS 如何把 Face Landmarker 的輸出結果打包,再過橋送到 Flutter;今天換 Android。兩個平台的通道名稱、參數、錯誤碼、事件欄位完全一致,所以 Dart 端只要寫一份。
# plugin 端:packages/face_mesh/pubspec.yaml
flutter:
plugin:
platforms:
android:
package: tw.aarontsai.face_mesh
pluginClass: FaceMeshPlugin
ios:
pluginClass: FaceMeshPlugin
# App 端 pubspec.yaml
dependencies:
face_mesh:
path: packages/face_mesh
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// MethodChannel:一次性的指令(開始/停止)
methodChannel = MethodChannel(binding.binaryMessenger, "face_mesh/method")
methodChannel.setMethodCallHandler(this)
// EventChannel:原生端持續推送的偵測結果
eventChannel = EventChannel(binding.binaryMessenger, "face_mesh/events")
eventChannel.setStreamHandler(this)
// PlatformView:相機預覽
binding.platformViewRegistry.registerViewFactory("face_mesh/preview", PreviewViewFactory())
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
stop()
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
}
這是 iOS 沒有的步驟。
CameraX 必須綁定在一個 LifecycleOwner 上,跳出權限詢問也需要 Activity,所以 plugin 要實作 ActivityAware 來拿到 Activity。App 的 MainActivity 繼承的 FlutterActivity 本身就是 LifecycleOwner。
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addRequestPermissionsResultListener(this)
}
override fun onDetachedFromActivity() {
// 相機綁在這個 Activity 的 lifecycle 上,Activity 走了就一起收掉
stop()
activityBinding?.removeRequestPermissionsResultListener(this)
activityBinding = null
pendingPermission = null
}
override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity()
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) =
onAttachedToActivity(binding)
iOS 的 requestAccess 會直接給 callback。Android 的權限結果則是回到 Activity,只有 plugin 收得到,所以這段邏輯寫在 plugin 裡:
private fun requestCameraPermission(activity: Activity, completion: (Boolean) -> Unit) {
val permission = Manifest.permission.CAMERA
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
completion(true)
return
}
// 上一次的詢問還沒回來(例如使用者連點兩次),不要疊第二個系統對話框
if (pendingPermission != null) {
completion(false)
return
}
pendingPermission = completion
ActivityCompat.requestPermissions(activity, arrayOf(permission), CAMERA_REQUEST_CODE)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
): Boolean {
if (requestCode != CAMERA_REQUEST_CODE) return false
val completion = pendingPermission ?: return true
pendingPermission = null
completion(grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED)
return true
}
start 的順序是「權限 → 模型 → 相機」,每個環節失敗都回傳對應的錯誤碼:
// …省略:取得 activity、owner
requestCameraPermission(activity) { granted ->
if (!granted) {
result.error("camera_failed", "使用者未授權相機權限", null)
return@requestCameraPermission
}
if (detect) {
val service = try {
FaceLandmarkerService.create(activity)
} catch (e: FaceLandmarkerService.ModelMissing) {
result.error("no_model", "找不到 face_landmarker.task,請確認 assets 有被打包進去", null)
return@requestCameraPermission
} catch (e: Exception) {
result.error("landmarker_failed", "MediaPipe FaceLandmarker 初始化失敗", e.message)
return@requestCameraPermission
}
service.onResult = ::emit
this.service = service
CameraSession.onFrame = service::detect
} else {
CameraSession.onFrame = null // detect = false:只開預覽,不載入模型
}
CameraSession.start(activity, owner, analyze = detect) { error ->
if (error != null) {
stop() // 失敗就立刻釋放 MediaPipe 的 native 記憶體
result.error("camera_failed", error, null)
} else {
running = true
detecting = detect
result.success(null)
}
}
}
private fun emit(frame: FaceFrame?) {
// MediaPipe 的 callback 不在 main thread,但 EventSink 必須在 main thread 呼叫
mainHandler.post {
val sink = eventSink ?: return@post
if (frame == null) {
sink.success(mapOf("hasFace" to false)) // 沒偵測到臉也回報
return@post
}
sink.success(
mapOf(
"hasFace" to true,
"blendshapes" to frame.blendshapes,
"yaw" to frame.yaw,
"rollDegrees" to frame.rollDegrees,
"mouthOpenRatio" to frame.mouthOpenRatio,
"mouthWidthRatio" to frame.mouthWidthRatio,
"contour" to frame.contour, // FloatArray 會自動編成 Dart 的 Float32List
"imageWidth" to frame.imageWidth,
"imageHeight" to frame.imageHeight,
)
)
}
}
internal class PreviewPlatformView(context: Context) : PlatformView {
private val previewView = PreviewView(context).apply {
setBackgroundColor(Color.BLACK)
scaleType = PreviewView.ScaleType.FILL_CENTER // 等同 iOS 的 resizeAspectFill
implementationMode = PreviewView.ImplementationMode.COMPATIBLE // 最容易踩的坑
}
init {
CameraSession.attach(previewView)
}
override fun getView(): View = previewView
override fun dispose() {
CameraSession.detach(previewView)
}
}
一定要用 COMPATIBLE:
預設的 PERFORMANCE 模式會優先使用 SurfaceView。AndroidView 是把原生 View 畫到 Flutter 的 texture 上再合成,而 SurfaceView 的畫面在另一個獨立的 surface 上,不會被畫進去,所以只看得到黑畫面。部分裝置會自動退回 TextureView,所以常見的狀況是有些手機正常、有些黑畫面。改成 COMPATIBLE 就一律使用 TextureView。
// face_mesh.dart
class FaceMesh {
static const MethodChannel _method = MethodChannel('face_mesh/method');
static const EventChannel _events = EventChannel('face_mesh/events');
static Future<void> start({bool detect = true}) =>
_method.invokeMethod<void>('start', {'detect': detect});
static Future<void> stop() => _method.invokeMethod<void>('stop');
static Stream<FaceFrame> get frames => _events
.receiveBroadcastStream()
.map((event) => FaceFrame.fromMap(event as Map<Object?, Object?>));
}
預覽依平台選擇對應的 view,viewType 要和步驟 2 註冊的名稱相同:
if (defaultTargetPlatform == TargetPlatform.android) {
return const AndroidView(
viewType: 'face_mesh/preview',
creationParamsCodec: StandardMessageCodec(),
);
}
return const UiKitView(
viewType: 'face_mesh/preview',
creationParamsCodec: StandardMessageCodec(),
);
Google 官方目前沒有提供 Face Landmarker 的 Flutter 套件,所以在 Flutter 使用 Face Landmarker 有兩種做法:使用社群套件,或自己橋接原生端。
pub.dev 上較具代表性的套件:
| 套件 | 做法 | 平台 | Blendshape |
|---|---|---|---|
| mediapipe_face_mesh | 內建 TFLite,直接執行 MediaPipe 模型 | Android、iOS、Windows | 有 |
| face_detection_tflite | 內建 TFLite,直接執行 MediaPipe 模型 | Android、iOS、macOS、Windows、Linux、Web | 有 |
| google_mlkit_face_detection | Google ML Kit(非 MediaPipe) | Android、iOS | 無 |
兩者最大的差別在相機影格的傳遞路徑:
- 社群套件:多半搭配 camera 套件使用,每張影格會先複製進 Dart,再交給模型。
- 自己橋接:
影格可以留在原生端,只把結果傳進 Dart。
在 Android 實機(Samsung Galaxy A70)上實測:
camera 套件以中等畫質(720×480)串流時,每張影格約 0.74 MB,每秒約 30 張,合計約 22 MB 的影格資料傳進 Dart。
本專案每張影格的結果整包約 1.8 KB(其中臉部特徵點約 0.7 KB),每秒約 21 張,合計約 40 KB。
| 社群套件 | 自己橋接 | |
|---|---|---|
| 開發成本 | 低,加入套件即可使用 | 高,兩個平台各寫一套原生程式 |
| 影格路徑 | 先複製進 Dart | 留在原生端 |
| 可控性 | 只能用套件開放的選項 | 輸出內容、鏡像、解析度都可自訂 |
| 維護 | 依賴套件作者 | 自行維護 |
今天我們一起迎請了俠客 Face Landmarker ,從模型導入再到橋接原生端,也將過程以檢查表、步驟的方式寫在文章中。
希望我們都能對於實作的過程與步驟更熟悉!
明天就讓我們進入 Google MediaPipe Face Landmarker 系列的最後一篇!
感謝有緣看到這邊的你~
希望佛菩薩也祝福你:🌟幸福快樂 健康自在🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️